Gdzie Saga to za dużo
Cześć!
Czy Saga może być antywzorcem? Tak daleko bym nie poszedł, ale są sytuacje gdy jej użycie to za dużo.
Dwa tygodnie temu opisywałem Sagę. Wyjaśniałem co to za wzorzec i dlaczego warto go znać. Pokazałem na przykładzie ze swojego repozytorium (https://github.com/oskardudycz/EventSourcing.NetCore/blob/main/Workshops/PracticalEventSourcing/Orders/Orders/Orders/OrderSaga.cs), że saga:
- umożliwia efektywne zarządzanie procesami rozproszonymi,
- dzieli proces na mikrotransakcje,
- jest takim dyspozytorem, który dostaje wiadomość (zdarzenie) od jednego serwisu, i wysyła kolejną (komendę) do drugiego,
- nie posiada stanu i podejmuje decyzje tylko na podstawie danych z otrzymanych wiadomości,
- umożliwia też zarządzanie operacjami korekcyjnymi (gdy np. nie powiodła się płatność potwierdzonego zamówienia).
W skrócie - bardzo przydatny wzorzec w systemach rozproszonych, mikroserwsiach. Ale czy tylko w nich?
Transakcja jest to pojęcie nieodzownie łączone z bazami danych lub operacjami finansowymi. O transakcjach, możemy myśleć też jak o operacjach atomowych - czyli takich, które albo się wydarzyły, albo nie. Nie pozostawiają po sobie śmieci.
Często procesy również w monolitach składają się z wielu operacji atomowych - np. po opłaceniu zamówienia powinniśmy wygenerować fakturę. Taka faktura poza wpisem w bazie danych musi mieć wygenerowany PDF. Ten plik potem powinien zostać automatycznie wysłany mailem. Nie musimy mieć systemów rozproszonych by mieć niezależne atomowe operacje nad którymi nijak nie rozepniemy transakcji:
- zapis do bazy,
- wygenerowanie pliku PDF na dysku,
- wysłanie maila z załączonym PDF.
Gdy zapis do bazy się nie powiedzie, to nie powinniśmy wysyłać pliku PDF. Gdy wygenerowanie pliku się nie powiedzie bo np. już istnieje inny z taką nazwą to powinniśmy np. spróbować dodać go ze zmienioną itd. itp.
W takiej sytuacji Saga również świetnie się sprawdzi. Mimo, że nie mówimy tutaj ani o operacjach rozproszonych, ani o transakcjach bazodanowych, ani o transakcjach finansowych.
No dobrze, ale miałem mówić gdzie się nie sprawdzi…
Książkowo Saga powinna być taką kanapką - otrzymujemy zdarzenie (np. o opłaceniu zamówienia - OrderPaid) i wysyłamy komendę (np. wygeneruj fakturę IssueInvoice). Następnie po zakończeniu tej komendy zostanie wygenerowane kolejne zdarzenie (np. wygenerowano fakturę - InvoiceIssued), po której wyślemy kolejną komendę (np. wydrukuj fakturę - PrintInvoice), itd. itp. aż do zakończenia procesu.
Wzorcowo zdarzenie jest tylko informacją o czymś, cos się wydarzyło. Nie powinno decydować, o tym, co się ma wydarzyć. Innymi słowy - zdarzenie to taki broadcast. Może być obsłużone przez wielu nasłuchujących według ich własnej interpretacji. Komenda z kolei powinna być skierowana do jednego obsługującego. W praktyce jednak gdy modelujemy proces to często okazuje się, że jedno zdarzenie zostanie obsłużone zawsze przez jeden handler. Dodatkowo po obsłudze tego zdarzenia zawsze tylko jedna rzecz ma się wydarzyć. Różnice pomiędzy komendą, a zdarzeniem często się zacierają (polecam prelekcję o tym: https://www.youtube.com/watch?v=uSF5hyfez60).
Czasem taki przekładaniec jest przerostem formy nad treścią. Sam w poprzednim projekcie popełniłem taki błąd. Pracowałem w module finansowych i na podstawie zdarzenia utworzenia rezerwacji tworzyiśmy obiekt konta finansowego. Chciałem być taki elegancki i zgodny z teorią i tworzyłem do każdego takiego procesu sagę. W pewnym momencie zorientowałem się, że tak naprawdę to generuje tylko nadmiarowy kod. Moje sagi zawsze miały tylko jeden krok (obsłuż zdarzenie zewnętrzne i wyślij komendę wewnętrzną). Dodatkowo do każdego z takich zdarzeń musiałem generować dodatkową komendę. W sumie 2 klasy więcej, plus ich obsługa.
Nie jest to może jakiś wielki problem, ale jednak coś co jest upierdliwe i robi się koniec końców dla samej zasady. Ostatecznie zrezygnowałem z Sagi i po prostu w Event Handlerze robiłem to co pierwotnie miałem w Command Handler.
Innym przykładem są reguły unikalności - np. generowanie numerów faktury itd. Takie tematy w systemach rozproszonych są dosyć trudne do zapewnienia. Zwykle jest to proces na zasadzie:
- pobierz numer,
- spróbuj dodać rekord z tym numerem,
- jak się udało to idź dalej,
- jak ktoś dodał w między czasie rekord z takim numerem i masz teraz dwa z nim to wycofaj zmianę.
Saga jest naturalnym rozwiązaniem tego tematu.
Dodatkowo w Event Sourcing ciężko jest założyć jakiś unique, bo każde zdarzenie ma swoje inne dane i jest interpretowane niezależnie. Dlatego przyjęło się, że w Event Sourcing do tego typi problemów powinno się używać sagi. Oczywiście można i będzie to poprawne, ale…
Jeśli używamy Marten, czyli Event Store, który ma pod spodem bazę relacyjną to możemy zrobić sobie pewien skrót. W Marten wszystkie dane zapisywane są w ramach transakcji bazodanowej. Mamy też możliwość generowania snapshotów i projekcji również w tej samej transakcji. Czyli jak np. dodajemy zdarzenie o utworzeniu faktury to od razu nam się zapisze w tej samej transakcji snapshot z aktualnym stanem faktury w postaci nowego dokumentu w tabeli. Dzięki temu możemy sobie na modelu dokumentu założyć unikalny klucz na polu faktury. Mając to jeśli numer już jest wykorzystany to więzy unkalności rzucą błędem i ani snapshot nie zostanie zapisany ani tym bardziej zdarzenie. Więcej do przeczytania:
- https://martendb.io/documentation/documents/customizing/unique/,
- https://martendb.io/documentation/events/projections/
Ktoś powie, że to hack. Ja to nazywam pragmatyzmem. Drogi na skróty bywają ryzykowne. Trzeba wiedzieć co się robi. Jednak nie powinniśmy na siłę się zmuszać do stosowania wzorców (nawet najlepszych) tylko dlatego, że w książkach tak napisano. Każdy problem jest inny, powinniśmy dążyć do dobierania rozwiązań pod problem, a nie odwrotnie. Generalnie im mniej ceremonii tym lepiej.
Pragmatyzm FTW!
Daj znać czy się ze mną zgadzasz czy nie. Czy widzisz inne sytuacje gdy Saga jest przerostem formy nad treścią?
Pozdrawiam!
Oskar